Don't use an `NSNumber` key in a dictionary which will be saved as a preference,...
[adiumx.git] / Frameworks / Adium Framework / Source / AIStatusMenu.m
blobb3da04a9383cf0ab726d68f861599f08eca8272d
1 //
2 //  AIStatusMenu.m
3 //  Adium
4 //
5 //  Created by Evan Schoenberg on 11/23/05.
6 //
8 #import <Adium/AIStatusMenu.h>
9 #import <Adium/AIStatus.h>
10 #import <Adium/AIStatusGroup.h>
11 #import <Adium/AIAccount.h>
12 #import <Adium/AIStatusControllerProtocol.h>
13 #import <Adium/AIEditStateWindowController.h>
14 #import <Adium/AIStatusIcons.h>
15 #import <Adium/AIAccountControllerProtocol.h>
16 #import <Adium/AIMenuControllerProtocol.h>
17 #import <Adium/AIPreferenceControllerProtocol.h>
18 #import <AIUtilities/AIArrayAdditions.h>
19 #import <AIUtilities/AIEventAdditions.h>
20 #import <AIUtilities/AIMenuAdditions.h>
21 #import <AIUtilities/AIStringAdditions.h>
23 #define STATUS_TITLE_CUSTOM                     [AILocalizedString(@"Custom", nil) stringByAppendingEllipsis]
24 #define STATE_TITLE_MENU_LENGTH         30
26 @interface AIStatusMenu (PRIVATE)
27 - (id)initWithDelegate:(id)inDelegate;
28 @end
30 @implementation AIStatusMenu
32 + (id)statusMenuWithDelegate:(id)inDelegate
34         return [[[self alloc] initWithDelegate:inDelegate] autorelease];
37 - (id)initWithDelegate:(id)inDelegate
39         if ((self = [super init])) {
40                 delegate = inDelegate;
41                 
42                 NSParameterAssert([delegate respondsToSelector:@selector(statusMenu:didRebuildStatusMenuItems:)]);
44                 menuItemArray = [[NSMutableArray alloc] init];
45                 stateMenuItemsAlreadyValidated = [[NSMutableSet alloc] init];
47                 [self rebuildMenu];
49                 [[adium notificationCenter] addObserver:self
50                                                                            selector:@selector(stateArrayChanged:)
51                                                                                    name:AIStatusStateArrayChangedNotification
52                                                                                  object:nil];
53                 [[adium notificationCenter] addObserver:self
54                                                                            selector:@selector(activeStatusStateChanged:)
55                                                                                    name:AIStatusActiveStateChangedNotification
56                                                                                  object:nil];
57                 
58                 //Update our state menus when the state array or status icon set changes
59                 [[adium notificationCenter] addObserver:self
60                                                                            selector:@selector(statusIconSetChanged:)
61                                                                                    name:AIStatusIconSetDidChangeNotification
62                                                                                  object:nil];
63         }
64         
65         return self;
68 - (void)dealloc
70         [[adium notificationCenter] removeObserver:self];
71         [stateMenuItemsAlreadyValidated release];
72         [menuItemArray release];
74         delegate = nil;
76         [super dealloc];
79 - (void)setDelegate:(id)inDelegate
81         delegate = inDelegate;
84 /*!
85  * @brief The delegate is just too good for the menu items we've created; it will create all of the ones it wants on its own
86  */
87 - (void)delegateWillReplaceAllMenuItems
89         //Remove the menu items from needing update
90         [stateMenuItemsAlreadyValidated removeAllObjects];
92         //Clear the array itself
93         [menuItemArray removeAllObjects];       
96 /*!
97  * @brief The delegate created its own menu items it wants us to track and update
98  */
99 - (void)delegateCreatedMenuItems:(NSArray *)addedMenuItems
101         //Now add the items we were given
102         [menuItemArray addObjectsFromArray:addedMenuItems];
105 - (void)stateArrayChanged:(NSNotification *)notification
106 {       
107         [self rebuildMenu];
110 - (void)activeStatusStateChanged:(NSNotification *)notification
112         [stateMenuItemsAlreadyValidated removeAllObjects];
115 - (void)statusIconSetChanged:(NSNotification *)notification
117         [self rebuildMenu];     
121  * @brief Generate the custom menu item for a status type
122  */
123 - (NSMenuItem *)customMenuItemForStatusType:(AIStatusType)statusType
125         NSMenuItem *menuItem;
126         
127         menuItem = [[NSMenuItem alloc] initWithTitle:STATUS_TITLE_CUSTOM
128                                                                                   target:self
129                                                                                   action:@selector(selectCustomState:)
130                                                                    keyEquivalent:@""];
131         
132         [menuItem setImage:[AIStatusIcons statusIconForStatusName:nil
133                                                                                                    statusType:statusType
134                                                                                                          iconType:AIStatusIconMenu
135                                                                                                         direction:AIIconNormal]];
136         [menuItem setTag:statusType];
137         
138         return [menuItem autorelease];
139                                 
143 * @brief Add state menu items
145  * Adds all the necessary state menu items to a plugin's state menu
146  * @param stateMenuPlugin The state menu plugin we're updating
147  */
148 - (void)rebuildMenu
150         NSEnumerator                    *enumerator;
151         NSMenuItem                              *menuItem;
152         AIStatus                                *statusState;
153         AIStatusType                    currentStatusType = AIAvailableStatusType;
154         AIStatusMutabilityType  currentStatusMutabilityType = AILockedStatusState;
156         [[adium menuController] delayMenuItemPostProcessing];
157         
158         if ([delegate respondsToSelector:@selector(statusMenu:willRemoveStatusMenuItems:)]) {
159                 [delegate statusMenu:self willRemoveStatusMenuItems:menuItemArray];
160         }
162         [menuItemArray removeAllObjects];
163         [stateMenuItemsAlreadyValidated removeAllObjects];
165         /* Create a menu item for each state.  States must first be sorted such that states of the same AIStatusType
166                 * are grouped together.
167                 */
168         enumerator = [[[adium statusController] sortedFullStateArray] objectEnumerator];
169         while ((statusState = [enumerator nextObject])) {
170                 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
171                 AIStatusType thisStatusType = [statusState statusType];
172                 AIStatusType thisStatusMutabilityType = [statusState mutabilityType];
173                 
174                 if ((currentStatusMutabilityType != AISecondaryLockedStatusState) &&
175                         (thisStatusMutabilityType == AISecondaryLockedStatusState)) {
176                         //Add the custom item, as we are ending this group
177                         [menuItemArray addObject:[self customMenuItemForStatusType:currentStatusType]];
178                         
179                         //Add a divider when we switch to a secondary locked group
180                         [menuItemArray addObject:[NSMenuItem separatorItem]];
181                 }
182                 
183                 //We treat Invisible statuses as being the same as Away for purposes of the menu
184                 if (thisStatusType == AIInvisibleStatusType) thisStatusType = AIAwayStatusType;
185                 
186                 /* Add the "Custom..." state option and a separatorItem before beginning to add items for a new statusType
187                         * Sorting the menu items before enumerating means that we know our statuses are sorted first by statusType
188                         */
189                 if ((currentStatusType != thisStatusType) &&
190                         (currentStatusType != AIOfflineStatusType)) {
191                         
192                         //Don't include a Custom item after the secondary locked group, as it was already included
193                         if ((currentStatusMutabilityType != AISecondaryLockedStatusState)) {
194                                 [menuItemArray addObject:[self customMenuItemForStatusType:currentStatusType]];
195                         }
196                         
197                         //Add a divider
198                         [menuItemArray addObject:[NSMenuItem separatorItem]];
199                         
200                         currentStatusType = thisStatusType;
201                 }
203                 menuItem = [[NSMenuItem alloc] initWithTitle:[AIStatusMenu titleForMenuDisplayOfState:statusState]
204                                                                                           target:self
205                                                                                           action:@selector(selectState:)
206                                                                            keyEquivalent:@""];
207                 
208                 if ([statusState isKindOfClass:[AIStatus class]]) {
209                         [menuItem setToolTip:[statusState statusMessageTooltipString]];
210                         
211                 } else {
212                         /* AIStatusGroup */
213                         [menuItem setSubmenu:[(AIStatusGroup *)statusState statusSubmenuNotifyingTarget:self
214                                                                                                                                                                          action:@selector(selectState:)]];
215                 }
216                 [menuItem setRepresentedObject:[NSDictionary dictionaryWithObject:statusState
217                                                                                                                                    forKey:@"AIStatus"]];
218                 [menuItem setTag:currentStatusType];
219                 [menuItem setImage:[statusState menuIcon]];
220                 [menuItemArray addObject:menuItem];
221                 [menuItem release];
222                 
223                 currentStatusMutabilityType = thisStatusMutabilityType;
224                 [pool release];
225         }
226         
227         if (currentStatusType != AIOfflineStatusType) {
228                 /* Add the last "Custom..." state optior for the last statusType we handled,
229                 * which didn't get a "Custom..." item yet.  At present, our last status type should always be
230                 * our AIOfflineStatusType, so this will never be executed and just exists for completeness.
231                 */
232                 [menuItemArray addObject:[self customMenuItemForStatusType:currentStatusType]];
233         }
234         
235         //Now that we are done creating the menu items, tell the plugin about them
236         [delegate statusMenu:self didRebuildStatusMenuItems:menuItemArray];
237         
238         [[adium menuController] endDelayMenuItemPostProcessing];
242 * @brief Menu validation
244  * Our state menu items should always be active, so always return YES for validation.
246  * Here we lazily set the state of our menu items if our stateMenuItemsAlreadyValidated set indicates it is needed.
248  * Random note: stateMenuItemsAlreadyValidated will almost never have a count of 0 because separatorItems
249  * get included but never get validated.
250  */
251 - (BOOL)validateMenuItem:(NSMenuItem *)menuItem
253         if (![stateMenuItemsAlreadyValidated containsObject:menuItem]) {
254                 NSDictionary    *dict = [menuItem representedObject];
255                 AIAccount               *account = [dict objectForKey:@"AIAccount"];
256                 AIStatus                *menuItemStatusState = [dict objectForKey:@"AIStatus"];
257                 
258                 if (account) {
259                         /* Account-specific menu items */
260                         AIStatus                *appropiateActiveStatusState;
261                         appropiateActiveStatusState = [account statusState];
262                         
263                         /* Our "Custom..." menu choice has a nil represented object.  If the appropriate active search state is
264                                 * in our array of states from which we made menu items, we'll be searching to match it.  If it isn't,
265                                 * we have a custom state and will be searching for the custom item of the right type, switching all other
266                                 * menu items to NSOffState.
267                                 */
268                         if ([[[adium statusController] flatStatusSet] containsObject:appropiateActiveStatusState]) {
269                                 //If the search state is in the array so is a saved state, search for the match
270                                 if ((menuItemStatusState == appropiateActiveStatusState) ||
271                                         ([menuItemStatusState isKindOfClass:[AIStatusGroup class]] &&
272                                          [(AIStatusGroup *)menuItemStatusState enclosesStatusState:appropiateActiveStatusState])) {
273                                         if ([menuItem state] != NSOnState) [menuItem setState:NSOnState];
274                                 } else {
275                                         if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
276                                 }
277                         } else {
278                                 //If there is not a status state, we are in a Custom state. Search for the correct Custom item.
279                                 if (menuItemStatusState) {
280                                         //If the menu item has an associated state, it's always off.
281                                         if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
282                                 } else {
283                                         //If it doesn't, check the tag to see if it should be on or off.
284                                         if ([menuItem tag] == [appropiateActiveStatusState statusType]) {
285                                                 if ([menuItem state] != NSOnState) [menuItem setState:NSOnState];
286                                         } else {
287                                                 if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
288                                         }
289                                 }
290                         }
291                 } else {
292                         /* General menu items */
293                         NSSet   *allActiveStatusStates = [[adium statusController] allActiveStatusStates];
294                         int             onState = (([allActiveStatusStates count] == 1) ? NSOnState : NSMixedState);
295                         
296                         if (menuItemStatusState) {
297                                 //If this menu item has a status state, set it to the right on state if that state is active
298                                 if ([allActiveStatusStates containsObject:menuItemStatusState] ||
299                                         ([menuItemStatusState isKindOfClass:[AIStatusGroup class]] &&
300                                          [(AIStatusGroup *)menuItemStatusState enclosesStatusStateInSet:allActiveStatusStates])) {
301                                         if ([menuItem state] != onState) [menuItem setState:onState];
302                                 } else {
303                                         if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
304                                 }
305                         } else {
306                                 //If it doesn't, check the tag to see if it should be on or off by looking for a matching custom state
307                                 NSEnumerator    *activeStatusStatesEnumerator = [allActiveStatusStates objectEnumerator];
308                                 NSSet                   *flatStatusSet = [[adium statusController] flatStatusSet];
309                                 AIStatus                *statusState;
310                                 BOOL                    foundCorrectStatusState = NO;
311                                 
312                                 while (!foundCorrectStatusState && (statusState = [activeStatusStatesEnumerator nextObject])) {
313                                         /* We found a custom match if our array of menu item states doesn't contain this state and
314                                         * its statusType matches the menuItem's tag.
315                                         */
316                                         foundCorrectStatusState = (![flatStatusSet containsObject:statusState] &&
317                                                                                            ([menuItem tag] == [statusState statusType]));
318                                 }
319                                 
320                                 if (foundCorrectStatusState) {
321                                         if ([menuItem state] != NSOnState) [menuItem setState:onState];
322                                 } else {
323                                         if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
324                                 }
325                         }
326                 }
327                 
328                 [stateMenuItemsAlreadyValidated addObject:menuItem];
329         }
330         
331         return YES;
335  * @brief Select a state menu item
337  * Invoked by a state menu item, sets the state corresponding to the menu item as the active state.
339  * If the representedObject NSDictionary has an @"AIAccount" object, set the state just for the appropriate AIAccount.
340  * Otherwise, set the state globally.
341  */
342 - (void)selectState:(id)sender
344         NSDictionary    *dict = [sender representedObject];
345         AIStatusItem    *statusItem = [dict objectForKey:@"AIStatus"];
346         AIAccount               *account = [dict objectForKey:@"AIAccount"];
347         
348         if ([statusItem isKindOfClass:[AIStatusGroup class]]) {
349                 statusItem = [(AIStatusGroup *)statusItem anyContainedStatus];
350         }
351         
352         /* Random undocumented feature of the moment... hold option and select a state to bring up the custom status window
353          * for modifying and then setting it.
354          */
355         if ([NSEvent optionKey]) {
356                 [AIEditStateWindowController editCustomState:(AIStatus *)statusItem
357                                                                                          forType:[statusItem statusType]
358                                                                                   andAccount:account
359                                                                           withSaveOption:YES
360                                                                                         onWindow:nil
361                                                                          notifyingTarget:[adium statusController]];
362                 
363         } else {
364                 if (account) {
365                         BOOL shouldRebuild;
366                         
367                         shouldRebuild = [[adium statusController] removeIfNecessaryTemporaryStatusState:[account statusState]];
368                         [account setStatusState:(AIStatus *)statusItem];
369                         
370                         //Enable the account if it isn't currently enabled
371                         if (![account enabled] && [statusItem statusType] != AIOfflineStatusType) {
372                                 [account setEnabled:YES];
373                         }
374                         
375                         if (shouldRebuild) {
376                                 //Rebuild our menus if there was a change
377                                 [[adium notificationCenter] postNotificationName:AIStatusStateArrayChangedNotification object:nil];
378                         }
379                         
380                 } else {
381                         [[adium statusController] setActiveStatusState:(AIStatus *)statusItem];
382                 }
383         }
387  * @brief Select the custom state menu item
389  * Invoked by the custom state menu item, opens a custom state window.
390  * If the representedObject NSDictionary has an @"AIAccount" object, configure just for the appropriate AIAccount.
391  * Otherwise, configure globally.
392  */
393 - (IBAction)selectCustomState:(id)sender
395         NSDictionary    *dict = [sender representedObject];
396         AIAccount               *account = [dict objectForKey:@"AIAccount"];
397         AIStatusType    statusType = [sender tag];
398         AIStatus                *baseStatusState;
399         
400         if (account) {
401                 baseStatusState = [account statusState];
402         } else {
403                 baseStatusState = [[adium statusController] activeStatusState];
404         }
405         
406         /* If we are going to a custom state of a different type, we don't want to prefill with baseStatusState as it stands.
407          * Instead, we load the last used status of that type.
408          */
409         if (([baseStatusState statusType] != statusType)) {
410                 NSDictionary *lastStatusStates = [[adium preferenceController] preferenceForKey:@"LastStatusStates"
411                                                                                                                                                                   group:PREF_GROUP_STATUS_PREFERENCES];
412                 NSData          *lastStatusStateData = [lastStatusStates objectForKey:[[NSNumber numberWithInt:statusType] stringValue]];
413                 AIStatus        *lastStatusStateOfThisType = (lastStatusStateData ?
414                                                                                                   [[NSKeyedUnarchiver unarchiveObjectWithData:lastStatusStateData] objectAtIndex:0] :
415                                                                                                   nil);
417                 baseStatusState = [[lastStatusStateOfThisType retain] autorelease];
418         }
419         
420         /* Don't use the current status state as a base, and when going from Away to Available, don't autofill the Available
421          * status message with the old away message.
422          */
423         if ([baseStatusState statusType] != statusType) {
424                 baseStatusState = nil;
425         }
426         
427         [AIEditStateWindowController editCustomState:baseStatusState
428                                                                                  forType:statusType
429                                                                           andAccount:account
430                                                                   withSaveOption:YES
431                                                                                 onWindow:nil
432                                                                  notifyingTarget:[adium statusController]];
435 #pragma mark -
436 #pragma mark Class methods
437 + (NSMenu *)staticStatusStatesMenuNotifyingTarget:(id)target selector:(SEL)selector
439         NSMenu                  *statusStatesMenu = [[NSMenu allocWithZone:[NSMenu menuZone]] init];
440         NSEnumerator    *enumerator;
441         AIStatus                *statusState;
442         AIStatusType    currentStatusType = AIAvailableStatusType;
443         NSMenuItem              *menuItem;
444         
445         [statusStatesMenu setMenuChangedMessagesEnabled:NO];
446         [statusStatesMenu setAutoenablesItems:NO];
447         
448         if (!target && !selector) {
449                 //Need to set a target and action for items with submenus (AIStatusGroups) to be selectable... so if we're not given one, set one.
450                 target = self;
451                 selector = @selector(dummyAction:);
452         }
453         
454         /* Create a menu item for each state.  States must first be sorted such that states of the same AIStatusType
455                 * are grouped together.
456                 */
457         enumerator = [[[[AIObject sharedAdiumInstance] statusController] sortedFullStateArray] objectEnumerator];
458         while ((statusState = [enumerator nextObject])) {
459                 AIStatusType thisStatusType = [statusState statusType];
461                 //We treat Invisible statuses as being the same as Away for purposes of the menu
462                 if (thisStatusType == AIInvisibleStatusType) thisStatusType = AIAwayStatusType;
464                 if (currentStatusType != thisStatusType) {
465                         //Add a divider between each type of status
466                         [statusStatesMenu addItem:[NSMenuItem separatorItem]];
467                         currentStatusType = thisStatusType;
468                 }
469         
470                 menuItem = [[NSMenuItem alloc] initWithTitle:[AIStatusMenu titleForMenuDisplayOfState:statusState]
471                                                                                           target:target
472                                                                                           action:selector
473                                                                            keyEquivalent:@""];
474         
475                 [menuItem setImage:[statusState menuIcon]];
476                 [menuItem setTag:[statusState statusType]];
477                 [menuItem setRepresentedObject:[NSDictionary dictionaryWithObject:statusState
478                                                                                                                                    forKey:@"AIStatus"]];
479                 if ([statusState isKindOfClass:[AIStatus class]]) {
480                         [menuItem setToolTip:[statusState statusMessageTooltipString]];
481                         
482                 } else {
483                         /* AIStatusGroup */
484                         [menuItem setSubmenu:[(AIStatusGroup *)statusState statusSubmenuNotifyingTarget:target
485                                                                                                                                                                          action:selector]];
486                 }
487                 
488                 [statusStatesMenu addItem:menuItem];
489                 [menuItem release];
490         }
491         
492         [statusStatesMenu setMenuChangedMessagesEnabled:YES];
493         
494         return [statusStatesMenu autorelease];
498 * @brief Determine a string to use as a menu title
500  * This method truncates a state title string for display as a menu item.
501  * Wide menus aren't pretty and may cause crashing in certain versions of OS X, so all state
502  * titles should be run through this method before being used as menu item titles.
504  * @param statusState The state for which we want a title
506  * @result An appropriate NSString title
507  */
508 + (NSString *)titleForMenuDisplayOfState:(AIStatusItem *)statusState
510         NSString        *title = [statusState title];
511         
512         /* Why plus 3? Say STATE_TITLE_MENU_LENGTH was 7, and the title is @"ABCDEFGHIJ".
513         * The shortened title will be @"ABCDEFG..." which looks to be just as long - even
514         * if the ellipsis is an ellipsis character and therefore technically two characters
515         * shorter. Better to just use the full string, which appears as being the same length.
516         */
517         if ([title length] > (STATE_TITLE_MENU_LENGTH + 3)) {
518                 title = [title stringWithEllipsisByTruncatingToLength:STATE_TITLE_MENU_LENGTH];
519         }
520         
521         return title;
524 + (void)dummyAction:(id)sender {};
526 @end